<?php

declare(strict_types=1);

namespace throttling;

use throttling\exception\ThrottleForbiddenException;
use think\facade\Cache;

/**
 * 请求限流类
 * 本类目前只能使用 think\Cache 并且必须是redis驱动
 * 每个漏斗的每次计数都会产生一个redis的key，访问量很大时比较占用内存，建议只在登陆等安全性较弱的接口使用
 * 
 * 如果全局个别场景使用，可以考虑使用 request()->ip()作为uniqueKey，如果多个方法分别使用，可以在IP上拼接控制器/方法名
 * 总之决定要 uniqueKey 就可以应对不同场景
 * 
 * 漏斗中计数水滴的移除是通过redis自带的TTL功能，所以不占用PHP的性能
 * 
 * @example
public function index()
{
	$ip = request()->ip();
	// new ThrottlingFunnel() 的第二个参数可传入配置项，具体参见代码
	if (!(new ThrottlingFunnel($ip))->inc()) {
		return '限流了';
	}
	return '访问成功';
}
 */
class ThrottlingFunnel
{
	/**
	 * 唯一标识（相当于一个用户或者一个请求IP）
	 * @var string
	 */
	protected $uniqueKey = '';

	/**
	 * 配置项
	 * @var array
	 */
	protected $config = [
		/** 水滴自动移除的时间间隔 */
		'out_dely' => 30,

		/** 最大水量 */
		'max_count' => 10,

		/** 储存前缀 */
		'perfix' => 'thr_',

		/** 储存数据分隔符 */
		'separator' => ':',
	];

	/**
	 * 驱动器实例
	 * @var mixed 
	 */
	protected $store;

	public static function getInstance(string $uniqueKey, $option = null)
	{
		return new static($uniqueKey, $option);
	}

	/**
	 * 构造方法
	 * @param string $uniqueKey 漏斗唯一标识 [!!注意!!] 不能包含这些符号 []|\/: 否则会失效，因为这些符号干扰了redis的key匹配
	 * @param array $option 配置项，完整配置项参考 $config ，这里需要改哪个就只传入哪个，setConfig()方法会自动合并，例如 $option = ['out_dely' => 10]
	 */
	public function __construct(string $uniqueKey, $option = null)
	{
		$this->uniqueKey = $uniqueKey;
		$this->setConfig($option);
	}

	public function getConfig($key = null)
	{
		return is_null($key) ? $this->config : $this->config[$key];
	}

	public function setConfig($key, $value = null)
	{
		if (is_null($key)) {
			return $this;
		} else if (is_array($key)) {
			$this->config = array_merge($this->config, $key);
		} else {
			if (isset($this->config[$key])) {
				$this->config[$key] = $value;
			}
		}
		return $this;
	}

	/**
	 * 获取当前计数
	 * @return int
	 */
	public function getCount(): int
	{
		$perfix = $this->getStoreKey() . $this->getConfig('separator') . '*';
		$maxCount = $this->getConfig('max_count');

		$count = 0;
		$cursor = null;

		while ($cursor !== 0) {
			$keys = $this->getStore()->scan($cursor, $perfix, $maxCount);
			$count += count($keys);
		}

		return $count;
	}

	/**
	 * 计数自增
	 * 相当于执行一次检查，有返回bool模式和抛出异常模式
	 * 抛出异常模式: 如果已经达到最大值，不执行计数增加，而是抛出 ThrottleForbiddenException
	 * 返回bool模式: 如果已经达到最大值，不执行计数增加，而是返回false，否则返回true
	 * @param bool $throwException 是否抛出异常
	 * @return boolean
	 * @throws ThrottleForbiddenException
	 */
	public function inc($throwException = false)
	{
		$current = $this->getCount();
		$maxCount = $this->getConfig('max_count');
		if ($current >= $maxCount) {
			if ($throwException) {
				throw new ThrottleForbiddenException($this->uniqueKey, $current, $maxCount, $this->getConfig('out_dely'));
			} else {
				return false;
			}
		}

		[$s, $ms] = $this->getStore()->time();
		$mstime = $s . $ms;

		$key = $this->getStoreKey() . $this->getConfig('separator') . $mstime;
		$ttl = (int)$this->getConfig('out_dely');
		$this->getStore()->set($key, 1, $ttl);
		return true;
	}

	/**
	 * 清空漏斗
	 * @return int 删除的水滴个数
	 */
	public function clear()
	{
		$cursor = null;
		$keys = $this->getStore()->scan($cursor, $this->getStoreKey() . $this->getConfig('separator') . '*');
		return $this->getStore()->del($keys);
	}

	/**
	 * == 如需修改实现驱动，可自行修改下列方法 ==
	 */
	protected function getStore()
	{
		if (is_null($this->store)) {
			$this->store = Cache::handler();
			try {
				$this->store->setOption(\Redis::OPT_SCAN, \Redis::SCAN_RETRY);
			} catch (\Error $e) {
				throw new \Exception('FunnelThrottle\\Throttle Error, this need think\Cache use redis as dirver, make sure "default" option is "redis" in file config/cache.php');
			}
		}
		return $this->store;
	}

	/**
	 * 获取储存前缀
	 * @return string
	 */
	protected function getStoreKey(): string
	{
		// Cache::getCacheKey() 是为了包装全局cache配置的前缀
		return Cache::getCacheKey($this->getConfig('perfix') . $this->uniqueKey);
	}
}
